feat: make releases atomic - #19
Merged
Merged
Conversation
Releasing was a six-step checklist in LIFECYCLE.md and the steps came apart. An audit of all sixteen published tags finds nine internally inconsistent: v2.1.0-v2.4.0 each ship a gates.yml fetching gate scripts from v2.0.0, and v3.0.0's Makefile.colormath points three releases back. v3.1.0 was stamped into main and written up in the changelog but never tagged, so main has been advertising a ref that 404s for every consumer running `make preflight`. The cause is stamping forward: writing the next version into the tree days before the tag exists, leaving a window that closes only if someone remembers. Stamps now move only in the release commit, which is tagged with the version it stamps in a single `git push --atomic` -- both refs land or neither does. release/cut.sh is the one gesture. release/verify.sh replaces refs-lockstep and is a strict superset: it covers plugin.json (a stamp site nothing checked, and which had been missed twice) and asks whether the stamped ref resolves, which is the question that would have caught both bugs. Documentation examples are de-versioned to @vX.Y.Z rather than stamped -- nine had rotted one to three majors stale. Tooling lives in release/, not scripts/: gates.yml sparse-checks-out `scripts` onto consumer runners at 16 call sites, and cone-mode sparse checkout is recursive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011614N8pZWRHsCE6HGpJNXm
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Releasing was a six-step checklist in
LIFECYCLE.md, and the steps came apart.release/verify.sh --audit-allon the current history — 9 of 16 published tags are internally inconsistent:v2.0.0v2.0.0v2.0.0And the live one:
maincurrently advertises a tag that does not exist.v3.1.0was stamped intogates.ymlandMakefile.colormathand written up in the changelog, but never tagged — soraw.githubusercontent.com/ColorMath/ci/v3.1.0/scripts/diff-coverage.sh404s. Anyone vendoringMakefile.colormathfrom main today gets amake preflightthat cannot fetch a single gate script.The old
refs-lockstepjob could not catch any of this: it compared the two stamps to each other and never asked whether the ref resolved. It also did not knowplugin.jsonexisted — a siteCHANGELOG.mdrecords having been missed once before.The cause, and the fix
The cause is stamping forward: a human writes the next version into the tree days before the tag exists, and the window closes only if they remember step 4.
So stamps now move only in the release commit, which is tagged with the version it stamps in a single
git push --atomic— git updates both refs or neither. The window is gone rather than shortened, and the invariant becomes true continuously:What's here
release/cut.sh— the one gesture. Refuses to start unless the tree is clean, synced withorigin/main, and green in CI; stamps; re-verifies; commits; creates an annotated tag; pushes atomically; publishes the GitHub Release from the changelog. Rolls back its own commit and tag if the push is rejected; resumes at the publish step if the tag landed butghfailed.release/verify.sh— replacesrefs-lockstep, strict superset. Coversplugin.json, asks whether the ref resolves, and requiresexample/'s vendored copies to stay byte-identical to root.--audit-allproduced the table above and stays advisory — published tags are never rewritten.release/stamp.sh— pure filesystem, idempotent, anchored patterns only. Historical prose (Default-on since v2.0.0, the adoption notes) is never touched.release/backfill-releases.sh— all 16 tags are bare; this fills them in from the changelog. Dry-run by default..github/workflows/release.yml— thinworkflow_dispatchwrapper so releases can be rehearsed locally rather than only inside Actions.This PR also clears the 404 by resetting both stamps to
v3.0.0, the tag that actually exists, and renaming the changelog'sv3.1.0heading back to## Unreleased. v3.1.0 then gets cut properly with the new machinery.Two deliberate choices
Docs are de-versioned, not stamped. Nine copy-paste pins had rotted (README said
@v2.0.0; the workflow usage-comments said@v1.1.0and@v1.0.0). They now read@vX.Y.Zand point at/releases/latest. Deleting the data beat automating it, andverify.shfails the PR if a concrete version reappears.Tooling is in
release/, notscripts/.gates.ymlsparse-checks-outscriptsonto consumer runners at 16 call sites, and cone-mode sparse checkout is recursive —scripts/release/would ship to every consumer CI run and become part of the published contract surface.Is the new gate safe to block?
LIFECYCLE.mdwarns that a hotfix blocked by a drift gate gets the gate disabled, "and that's the beginning of the end."release-consistencyis safe to block because it can only fail on a PR that edited a version string — a PR touching none of them inherits main's consistent state and passes unconditionally. It can never fire because the world moved.Verification
Exercised end-to-end against a throwaway bare remote:
cut.sh→ annotated tag, both refs landed in one atomic push, invariant holds on the release commit itself,--audit-allreports the new tag consistentpre-receivehook) → both refs rejected together, local commit and tag rolled back, working tree clean, origin unchanged🤖 Generated with Claude Code
https://claude.ai/code/session_011614N8pZWRHsCE6HGpJNXm